【Kotlin】Androidで連続して音声認識がしたい

【Kotlin】Androidで連続して音声認識がしたい

Androidで音声認識をずっと待ち受けたい
Clock Icon2024.11.29

こんにちは。リテールアプリ共創部のYahiroです。

先日音声認識を実装していて困ったのでまとめます。

この記事でわかること

  • Androidでタイムアウトせずに音声認識を続ける方法
  • VoskのAndroidにおける使い方

概要

ある日、上長よりスマホに触らずに画面を切り替えれるアプリが作れるか技術検証してほしい。と依頼がありました。

要件としては、該当の画面を表示している間はずっと音声認識をして、特定のワードを聞き取った時に画面遷移してほしいとのこと。

意気揚々と音声認識を実装して試してみたところ、iOSは問題なく実現可能でした。

ところが、Androidは何回やっても数秒でタイムアウトしてしまう。連続して音声認識をすることができないのです。さあ困りました。

そもそもAndroidってどうやって音声認識するの?

Androidには3種の音声認識の方法があります。

SpeechRecognizer

これは、アプリ開発者がデフォルトで使うライブラリ。Googleが音声認識エンジンを提供しているので、呼べば簡単に音声認識を実装できる。ただし、数秒または単語を聞き取った時点で終了してしまう。

VoiceInteractionService

これは、Googleアシスタントなどと同じくウェイクワードを使用できる。ユーザがOSレベルでデフォルトのアシスタントアプリを変更して使用するもの。

AudioRecord

これはそもそも音声認識というよりは、音の録音。
独自の音声認識エンジンを実装するか外部連携する必要がある。
用途的には、録音した生のデータを音声認識エンジンに送信するときとかに使うもの。

AndroidではSpeechRecognizerを使用するのが一般的です。

SpeechRecognizerを採用する問題

ところが、SpeechRecognizerは数秒でタイムアウトするという最大のデメリットがあります。

じゃあ、タイムアウトしたらまた開始するように組めばいいのではないか?という話になるのですが、SpeechRecognizerは音声認識開始時に「ピロン」と音が鳴ります。
そのため、連続して呼んでしまうと毎回音が鳴ってしまいます。

よって、そのようなアプローチを行ったとしてもあまり実用的ではないのです。

音声認識エンジンを組み込もう

前置きが長くなりましたが、SpeechRecognizerでは連続した音声認識の実装は難しいという結論になりました。
よって、音声認識ライブラリを組み込んでしまい、自前で音声認識機能を用意してしまおうというのが今回の記事になります。

Voskって?

Alpha Cephei Inc.が提供している音声認識ライブラリです。Vosk自体はMITライセンスで提供されています。

20種類以上の言語に対応しており、一つのモデルが約50MBと非常に軽量なためアプリなどに組み込みしやすいです。

実装方法

早速実装を進めていきます。今回、GradleはGroovyで記載を行っていますが、ktsの方は適宜読み替えてください。

まず初めに、Assetsフォルダを作成して、VoskのModelフォルダを追加します。

モデルは下記のURLから適切なものをダウンロードしてください。

今回は日本語のsmallモデルを使用します。

https://alphacephei.com/vosk/models

vosk-assets-androidstudio

ここでは日本語の軽量モデルを採用していますが、適宜読み替えてください。

まず、build.gradleにVoskの依存関係を追記します。VoskのライブラリおよびJNAのライブラリの追加を行います。

dependencies{
    implementation group: 'com.alphacephei', name: 'vosk-android', version: '0.3.32+'
    implementation "net.java.dev.jna:jna:5.8.0@aar"
}

次に、uuidの生成とフォルダの作成のタスクを追加します。

tasks.register('genUUID') {
    def uuid = UUID.randomUUID().toString()
    def odir = file("$buildDir/generated/assets/model/vosk-model-small-ja-0.22")
    def ofile = file("$odir/uuid")
    doLast {
        mkdir odir
        ofile.text = uuid
    }
}

終わったら忘れずにSyncを行いましょう。

次にAndroid Manifestに下記を追記します。

ユーザからマイクの使用権限を取得するために記載します。

<uses-permission android:name="android.permission.RECORD_AUDIO"/>

ここまでで下準備は完了です。いよいよMainActivityを実装していきます。

まずはMainActivityの宣言において、extendsにVoskライブラリのRecognitionListenerを追加します。

class MainActivity : AppCompatActivity(), org.vosk.android.RecognitionListener {

その他変数として下記を定義しておきます。

companion object {
    private const val PERMISSIONS_REQUEST_RECORD_AUDIO = 1
}

private var model: Model? = null
private var speechService: SpeechService? = null
private var speechStreamSercice: SpeechStreamService? = null

続いて、onCreateに下記を追加します。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    //省略

    val permissionCheck =
        ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.RECORD_AUDIO)
    if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(
            this,
            arrayOf(Manifest.permission.RECORD_AUDIO),
            PERMISSIONS_REQUEST_RECORD_AUDIO
        )
    } else {
        initModel()
    }

PERMISSION_REQUEST_RECORD_AUDIOはパーミッションリクエストを識別するためのユニークな値です。
そのため、ここでは別途PERMISSION_REQUEST_RECORD_AUDIOという定数にしていますが、定義方法はなんでも良いです。

ここで権限チェックがOKだった時に呼ばれるinitModel()の中身は下記になります。

private fun initModel() {
    StorageService.unpack(
        this,
        "model/vosk-model-small-ja-0.22",
        "model",
        { model: Model? ->
            this.model = model
            Log.d("Vosk", "Model loaded successfully")
        },
        { exception: IOException ->
            Log.e("Vosk", "Failed to unpack the model", exception)
        }
    )
}

ここでは、Voskの音声認識モデルの初期化を行なっています。

具体的には、先ほどassetsに追加したmodelフォルダをアプリの内部ストレージのディレクトリへの展開を行っています。

StorageServiceクラスはVoskのライブラリ内に内包されているものです。

さて、Voskのコールバックはそれぞれ下記になります。

override fun onPartialResult(hypothesis: String?) {
    //部分的な文字列の認識結果の取得
}
override fun onResult(hypothesis: String?) {
    //認識結果の取得
}
override fun onFinalResult(hypothesis: String?) {
    //最終的な認識結果の取得
}
override fun onError(exception: Exception?) {
    //エラーのコールバック
}
override fun onTimeout() {`
    //タイムアウトのコールバック
}

さて、この中から今回はonResultを使用して実装を進めていきます。

今回の要件は、画面表示中に音声を聞き取り、特定のワードを検出したときに画面遷移するというものでした。

onResultには、認識結果が常に入ってくるため、ここで特定のワードが入ってきたら処理をしたら良いということになります。

override fun onResult(hypothesis: String?) {
    hypothesis?.let { result ->
        when {
            result.contains("次") -> {
                // 次の画面に遷移する処理
            }

            result.contains("戻") -> {
                // 前の画面に戻る処理
            }

            else -> {
                // デフォルトの処理
            }
        }
    }
    Log.d("VoskonResult", hypothesis ?: "")
}

今回は、「次」と認識したら、次の画面に遷移する。「戻」と認識したら前の画面に戻るという処理にしました。

最後に、音声認識の開始と終了です。

音声認識開始

fun startRecognizeMicrophone() {
    if (speechService != null) {
        speechService?.stop()
        speechService = null
    } else {
        try {
            val rec = Recognizer(model, 16000.0f)
            speechService = SpeechService(rec, 16000.0f)
            speechService!!.startListening(this)
        } catch (e: IOException) {
            Log.d("Vosk", e.toString())
        }
    }
}

音声認識終了

fun stopRecognizeMicrophone() {
    if (speechService != null) {
        speechService?.stop()
        speechService = null
    } else {
        Log.d("Vosk", "Stop failed")
    }
}

コード全体

完成したコードが下記になります。なお、今回の実装に関係のない部分は省略しています。

またコールバックはそれぞれLogに出す処理を記載しているため、適宜変更してください。

このコードの中ではstartRecognizeMicrophone/stopRecognizeMicrophoneを呼び出す処理を記載していませんが、ボタンのリスナーなど適宜呼び出す処理を実装してください。

class MainActivity : AppCompatActivity(), org.vosk.android.RecognitionListener {

    private lateinit var binding: ActivityMainBinding

    companion object {
        private const val PERMISSIONS_REQUEST_RECORD_AUDIO = 1
    }

    private var model: Model? = null
    private var speechService: SpeechService? = null
    private var speechStreamSercice: SpeechStreamService? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        //省略

        val permissionCheck =
            ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.RECORD_AUDIO)
        if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.RECORD_AUDIO),
                PERMISSIONS_REQUEST_RECORD_AUDIO
            )
        } else {
            initModel()
        }

        //省略
    }

    private fun initModel() {
        StorageService.unpack(
            this,
            "model/vosk-model-small-ja-0.22",
            "model",
            { model: Model? ->
                this.model = model
                Log.d("Vosk", "Model loaded successfully")
            },
            { exception: IOException ->
                Log.e("Vosk", "Failed to unpack the model", exception)
            }
        )
    }

    fun startRecognizeMicrophone() {
        if (speechService != null) {
            speechService?.stop()
            speechService = null
        } else {
            try {
                val rec = Recognizer(model, 16000.0f)
                speechService = SpeechService(rec, 16000.0f)
                speechService!!.startListening(this)
            } catch (e: IOException) {
                Log.d("Vosk", e.toString())
            }
        }
    }

    fun stopRecognizeMicrophone() {
        if (speechService != null) {
            speechService?.stop()
            speechService = null
        } else {
            Log.d("Vosk", "Stop failed")
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        speechService?.stop()
        speechService = null

        speechStreamSercice?.stop()
    }

    override fun onPartialResult(hypothesis: String?) {
        Log.d("VoskonPartialResult", hypothesis ?: "")
    }

    override fun onResult(hypothesis: String?) {
        hypothesis?.let { result ->
            when {
                result.contains("次") -> {
                    // 次の画面に遷移する処理
                }

                result.contains("戻") -> {
                    // 前の画面に戻る処理
                }

                else -> {
                    // デフォルトの処理
                }
            }
        }

        Log.d("VoskonResult", hypothesis ?: "")
    }

    override fun onFinalResult(hypothesis: String?) {
        Log.d("VoskonFinalResult", hypothesis ?: "")
    }

    override fun onError(exception: Exception?) {
        Log.d("VoskonError", exception?.message ?: "")
    }

    override fun onTimeout() {
        Log.d("VoskonTimeout", "Timeout")
    }
}

最後に

今回Voskを初めて知りましたが、軽量モデルでもかなり音声認識の精度が高いことに驚きました。
他の言語でも利用可能みたいなので、機会があれば他のプラットフォームでも実装してみたいなと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.